BEP-21-Synaptic dynamics

Abstract:
	Currently, synaptic dynamics are only handled at the pre or post-synaptic
	neuron, rather than at the local synapse. For example, for a given
	postsynaptic neuron, there is a single
	equation for all synapses with the same dynamics (e.g. AMPA excitatory
	current) and all presynaptic spikes impact the same variable. In STDP,
	synaptic variables are considered as either presynaptic or postsynaptic,
	not specific of each synapse.
	This design implies strong constraint on synaptic models (kinetics
	and plasticity).
	This BEP aims at overcoming these limitations.
	
Note: this is now the Synapses class

Missing features and a few ideas
================================
* Nonlinear synapses (e.g. NMDA). These require one conductance variable per
  synapse, and separate dynamics. The idea is to
  define differential equations on synaptic weights.
  We could use a fake NeuronGroup as a proxy to synaptic weights + state updater.
  We could use a slower clock to speed up the simulation.
  Issues: 1) several synaptic variables, 2) heterogeneous delays, 3) routing
  spikes (now presynaptic neurons act on different variables).
  A priori, it is not possible to do event-driven dynamics, because of the
  interaction of the conductances with the membrane equation. But maybe there is
  a trick to be found.

* Probabilistic synapses.
  The main difficulty here is to integrate it with STP and STDP.

* Multiple synapses (with different delays) between the same pre/post neurons
  This is almost done, except we need to check if it works with STDP.
  We also need to think of a nice syntax.
  
* STDP: access to postsynaptic variables
  This is probably not a major problem (and can be done with the current version,
  although not in an elegant way), unless it is coupled with local synaptic
  variables. We have one example (Fusi's STDP rule) from people in the mailing
  list.

* STDP: rules with local synaptic variables
  Here we need event-driven dynamics. But one issue is dealing with heterogeneous
  delays. We should start with an existing example (STDP1/2). Then the most
  interesting rule is Graupner/Brunel's.
  
* Neuromodulation. There is a question on this in the Brian support list. This
  is also something that NEST supports. There are two issues here: 1) continuous
  weight dynamics, which should be computed with event-driven updates, 2)
  modulation by an external signal, which is a trace of spikes from other neurons.
  At this point, I'm not familiar enough with this to address point 2.
  
* Heterosynaptic modifications (see eg. Kempter et al. PNAS 2001). Synaptic modifications
  may not be local, for example may affect all synapses from the same presynaptic
  neuron.

The main technical difficulty, I think, is dealing with heterogeneous delays,
which might require event queues.

Multiple synapses
-----------------
Having multiple synapses (for a given pre/post pair) is entirely possible
with the current sparse connection structure (although STDP should be checked).
What is really needed is a nice syntax to create these synapses and
access the weights.

Nonlinear synapses
------------------
The classic example is NMDA synapses.
Reference:
Probabilistic decision making by slow reverberation in cortical circuits.
XJ Wang, Neuron 2002.

Model:
dg/dt=-a*g+b*x*(1-g)
dx/dt=-c*x
and spikes act on x (addition).

The conductance then enters the membrane equation.
The problem here is that it is a nonlinear system (product of x and g),
and therefore we cannot simply simulate the total conductance, as we do
with linear synapses ("lumped variable"). Each synapse must be individually
simulated. This implies a huge computational cost (proportional to
number of synapses/dt for each second). It cannot be event-driven because the
value of the total conductance is needed at each time step.

There is an example in the support list, where people used a NetworkOperation
to do it.

Tricks
^^^^^^
Example x(t) should be close to 0 most of the time, because 1/c represents the
rise time, which is short. When x=0, the equations are linear (linear decay
of g), and therefore we only need to simulate the lumped variable gtot(t)
corresponding to the sum of all conductances for which x=0. This could however be
quite painful to code.

Another approach is to use an approximation of NMDA dynamics:
dg/dt=-a*g+b*x
dx/dt=-c*x
and x->x+(1-g)*w

This makes sense because g(t) changes slowly relatively to x(t).
If we do that, the equations are linear and we only need to simulate
the lumped variables. However, we still need individual synaptic values
for g (not lumped) at spike time. Here we could use event-driven updates
of g - this is possible because the equations are linear. The cost is now
proportional to the number of transmitted spikes.
(N.B.: actually if we can do that this could make a short paper).

Again, we need 1) local synaptic variables, 2) event-driven updates.
In addition, we need to be able to modulate synaptic weights with
synaptic variables (perhaps implement a synaptic reset: "x=x+(1-g)*w").

Proposition: a new Connection class
===================================
I propose a new Connection class, let's call it Synapses for now.
It follows a similar syntax as STDP, but defines everything synaptic.
I propose that this should be the class used for code generation (GPU/C),
because it is a single class for everything synaptic.

Examples
--------
S=Synapses(source,target,model="w : 1",pre="v+=w") # standard synapse

S=Synapses(source,target,
           model='''dg/dt=-a*g+b*x*(1-g) : 1
                    dx/dt=-c*x : 1
                    w : 1 # synaptic weight
                 ''',
           pre='x+=w') # NMDA synapses

S=Synapses(source,target,
           model='''dA_pre/dt=-A_pre/tau_pre : 1
                    dA_post/dt=-A_post/tau_post : 1
                    w : 1 # synaptic weight
                 ''',
           pre='''x+=w
                  A_pre+=dA_pre;w+=A_post'''
           post='''A_post+=dA_post;w+=A_pre''') # STDP
           
S=Synapses(source,target,
           model='''dA_pre/dt=-A_pre/tau_pre : 1
                    dA_post/dt=-A_post/tau_post : 1
                    w : 1 # synaptic weight
                 ''',
           pre='''v+=w
                  A_pre+=dA_pre;w=clip(w+A_post,0,inf)'''
           post='''A_post+=dA_post;w=clip(w+A_pre,0,inf)''') # STDP with clipping

S=Synapses(source,target,
           model='''dx/dt=(1-x)/taud : 1
                    du/dt=(U-u)/tauf : 1
                    w : 1''',
           pre='''v=v+w*u*x
                  x=x*(1-u)
                  u=u+U*(1-u)
                  ''') # STP
                  
S=Synapses(source,target,
           model='''x : 1
                    u : 1
                    w : 1''',
           pre='''u=U+(u-U)*exp((t-lastspike)/tauf)
                  x=1+(x-1)*exp((t-lastspike)/taud)
                  v=v+w*u*x
                  x*=(1-u)
                  u+=U*(1-u)                  
                  ''') # Event-driven STP

S=Synapses(source,target,model="""w : 1
                                  p : 1 # transmission probability""",
                         pre="v+=w*(rand()<p)") # probabilistic synapse

Description
-----------
The keywords are basically the same as in STDP.
In the model (or eqs) string, we define equations and variables. All variables
are local synaptic variables, stored with an underlying sparse structure
(see below).

Data structure
--------------
When the equations are specified, synaptic variables are created. The structure
is a sparse matrix, but with multiple values. This means for each row:
* an array (or list, for construction) of target neurons.
* an array for each synaptic variable.
We also add a lastspike (or last_update) variable, which holds the time
of the last update of the synapse. This is useful for probabilistic synapses
(update is conditional on a random number). This could be optional (we just
check whether lastspike is modified in pre/post code), and if not used, it
could be calculated based on the last spikes of pre- and post-synaptic neurons
and delays (I would not advise it, though).

We also add two variables: delay_pre and delay_post. Delay_post is optional,
and is 0 by default. But these variables should be in the spike queues.

At construction time, these are lists of rows.
At run time, rows are concatenated into single arrays. It makes a 2D matrix,
where each row correspond to one variable (target neuron or synaptic variable).
The column index is the synapse index, and is used by spike queues.
Two additional arrays give the starting point and length of the rows. For
post->pre spikes, we also add a concatenated array of columns (for each column,
we have the array of presynaptic neurons, and we concatenate everything),
with two additional arrays with starting point and length of the columns.

Memory consumption for N neurons, K synapses (total, not per neuron):
* K*(1 + number of synaptic variables + 2(delays, possibly 1) + 1 (lastspike))
* Nsource*2
* K+Ntarget*2 (reverse access)

If we assume 8 bytes for double, 4 bytes for indexes (but could be 2 in many cases),
2 bytes for delays (in timesteps), 4 bytes for lastspike (in timesteps):
* K*(4 + 8*number of synaptic variables + 2(1)*2 + 4)
* Nsource*2*4
* K*4+Ntarget*2*4

So in total, including reverse access (only if there is a post code):
K*8*(n+2)+(Nsource+Ntarget)*8

For example, this is about 40 bytes per synapse for an STDP synapse
with pre and post delays. For a static synapse, this would be 14 bytes.

This does not include spike queues (to hold spike events), which should take
about K*4 bytes (this assumes that one neuron cannot spike twice given the
maximum delay).

The memory consumption does not seem unreasonable (apparently slightly lower
than NEST).

Equations
---------
Equations defined in the model should work as in NeuronGroup, except on
synaptic variables. Pre/post code should work as in STDP.
A few specificities:
* a non-defined variable is first assumed to be postsynaptic (otherwise global).
  One potential issue here is if we want to consider (dendritic) delays for
  postsynaptic variables (soma->synapse and converse).
* t holds the current time
* lastspike holds the time of last update of the synapse
* rand() is redefined or rewritten for vectorisation (this allows probabilistic
  synapses). We may want to have other random distributions such as binomial.
* We may also want a way to access presynaptic variables, for example to code
  gap junctions. One possibility is the explicit suffix _pre (e.g. v_pre).

One subtlety for the Python code (which is not an issue for GPU/C) is that
when executing something like 'v+=w', this will be interpreted as
'v[targets[synapses]]+=w[synapses]', but then there might be repeted indexes
in targets[synapses]. This does not work with Numpy. There are two options to
solve this problem:

1) v (in fact all postsynaptic variables) is an instance of a new class where
self addition and multiplication are overloaded (and possibly direct assignment
is forbidden). We then use a trick (see posts on the development list) using
sorting to implement it. This is not great because we can't implement everything
with this.

2) we separate 'synapses' in groups of synapse indexes with no repeated
postsynaptic neurons, then we execute the code sequentially for each group.
The idea is that in most practical cases, there should be no repetition. This
would not be so for very dense networks with cortex-like connectivity
(10,000 synapses per neuron), but there we will probably need to use code
generation anyway.

Option 2 looks efficient enough.

Event-driven updates
--------------------
Event-driven updates can be specified by the user. But it would be nice to
be able to write the differential equations, and let Brian calculate the
event-driven operations. This seems possible with linear equations, either
with sympy or by calculating the matrix exponential.

Lumped variables
----------------
For nonlinear synapses (and also gap junctions),
we need to be able to define a "lumped variable",
generally the total conductance, that is updated at every timestep as the
sum of all presynaptic values for each neuron (is there a fast way do it
in scipy.sparse?).

There are several possibilities for the syntax.
We could use something like linked_var, for example:
P.g=lumped_var(synapses.g)
where P is the NeuronGroup, and g must have been defined as a parameter
in P.

Can we come up with a better syntax? I don't think it could be in the model
definition of the neuron group, because the Synapses object is usually not
defined at that time.

Construction and access
-----------------------
We need some nice methods to build the connection structure and access synaptic
variables.

Currently, we can do things like:
* S[2,5]=1*nS
* S.connect_random(group1[0:20],group2[10:40],sparseness=0.02,weight=5*nS)
* S.connect_full(group1,group2,weight=lambda i,j:(1+cos(i-j))*2*nS)
and delays can also be set.

But now we may have more synaptic variables. One difficulty is that they have
a sparse structure, but with the same non-zero elements.

For direct access, I suggest to use a similar syntax as for NeuronGroups:
* S.w[2,5]=1*nS
* S.w[1,:]=2*nS
The first instruction sets w=1*nS for synapse 2->5.
The second instruction may be ambiguous: should all
elements be created, or should only non-zero elements be assigned?
If we have several synaptic variables, creating elements by these instructions,
or having different semantics depending on the variable, is dangerous.
Therefore the semantics is that assignment never creates synapses,
and we use a special syntax to create synapses:
  S[2,3:5]=True # or an array
This creates synapses 2->3 and 2->4.

We could also have several synapses per pre/post pair:
  S[2,3:5]=(2,3) # 2 synapses from 2 to 3, 3 from 2 to 4
Then access is with a third coordinate:
  S.w[2,3,1]  # this is the 2nd synapse on 2->3
And we can write:
  S.w[2,3]=(1*nS,2*nS)

I propose to allow this syntax:
  S.w[group1,group2]="(1+cos(i-j))*2*nS"
The advantage this syntax is that it can be used for code generation
(e.g. on GPU). Here we need two special names for pre and post indexes. These
could be i/j or pre/post.
This also works to create connections:
  S[group1,group2]='rand()<0.2'
which creates a sparse connection structure. One problem with this is that
it won't be efficient, as it requires O(N*N) operations, rather then the number
of synapses. There are two (possibly complementary) options to solve this.

1) We still have connect_random, connect_one_to_one, etc. But I suggest that at
least we replace lambda functions with strings. Also the weight keyword should
be replaced by synaptic variable names.
2) We use sparse boolean matrices (or integer matrices) on right hand sides:
  S[P,Q]=one_to_one(P,Q)
that is, the right hand side is a sparse boolean matrix (or possibly int matrix).
These boolean matrices could be also used for monitoring.
We could be inspired by Mikael Djurfeldt's "connection set algebra".

Synapse monitors
----------------
We use a StateMonitor. For example:
M = StateMonitor(S,'w',record=[(2,3),(7,1),(3,3)])
The main issue is to set record indexes. I suggest we use exactly the same
syntax as for access/construction above. This way, we can simply use a method
in the synapse object which turns (2,3) etc. into synapse indexes. It makes
coding the new monitor for Synapses trivial.

Spike queues
------------
In the Synapses class, pre/post operations must be applied at the right times,
not in advance. Therefore, in a given time step, with heterogeneous delays,
a Synapses object must receive spike events as a list of synapse indexes.
This is done by the spike queue class, which stores future synaptic events
produced by a given presynaptic neuron group.

The structure is a 2D array, where row is the time bin and column
is the position in that bin (each row is a stack) (or the converse).
The array is circular in the time dimension. There is a 1D array giving the
position of the last added event in each time bin.

Main methods:
* Output the current events: we simply get the first (or last) row (up to the
  last added event), so this is fast. We then shift the cursor of the circular
  array by one row.
* Feed events. Each presynaptic neuron has a corresponding
  array of target synapses et corresponding delays. We must push each target
  synapse (index) on top of the row corresponding to the delay. If all synapses
  have different delays, this is relatively easy to vectorize. It is a bit
  more difficult if there are synapses with the same delays.
  For a given presynaptic neuron, each synaptic delay corresponds to coordinates
  (i,j) in the circular array of stacks, where i is the delay (stack index) and
  j is index relative to the top of the stack (0=top, 1=1 above top).
  The absolute location in the structure is then calculated as n[i]+j, where
  n[i] is the location of the top of stack i. The only difficulty is to calculate
  j, and in Python this requires sorting (see development mailing list).
  It can be preprocessed if event feeding involves a loop over presynaptic spikes
  (if it's vectorized then it's not possible anymore). In this case it takes K*4
  bytes.
* Resizing. When a stack is full (row size exceeded), we double (or multiply
  by 1.5 or other) the size of the 2D array (number of columns). I note that if
  the 2D array is implemented in the other direction (columns instead of rows),
  this only means resizing the array without having to copy anything. On the
  other hand, it makes getting the current events a bit slower (because not
  continuous).

The whole class should also have a C version, which would be much faster.

Memory consumption: this is essentially the 2D circular array, which is dynamic.
Each entry is 4B (synapse index). The total size should be the maximum number
of synaptic events within the time span of the queue (max delay - min delay).
Within that time span, each neuron can spike no more than
p=(max delay - min delay)/refractory period, and therefore a given synapse index
cannot be present more than p times in the array. Thus, memory consumption is
always smaller than K*4*p. This does not seem very large (compare with K*40 for
an STDP synapse).

Advantages with this class:
* It concentrates all the hard to vectorize/time consuming part of the simulation.
  Therefore if we have a fast code for this, it may make simulations faster.
* The LS object is no longer useful. We only need to store the spikes at the
  current time step, and the time of the last spikes for each neuron (for
  event-driven updates).
* No more difference between homogeneous and heterogeneous delays. When delays
  are homogeneous, the code is (maybe) automatically faster because we have
  contiguous indexes in the 2D array.
* We can vectorize synaptic operations over synapses, not just over postsynaptic
  neurons, and therefore we can expect a speed gain.
* Could be good for GPU/C code generation.
* We can now use NEST's trick: if all delays and refractory periods are greater
  than a period delta, then we only need to transfer spikes to synapses
  every delta rather than every timestep. This should provide speed-ups for GPU
  and code generation, perhaps also cluster simulations if we want to do that.

Tasks
=====
A number of things can be done independently.

High priority:
* Write a few examples
* Sparse matrix structure with multiple values.
  [V] : Sort of done, by the state vector, even though it may not be completely efficient
* Separation of an array of synapse index into a list of arrays with no
  repeated postsynaptic neurons (synapse_separation.py)
  [V] : Done
* Spike queue, Python version (spikequeue.py, see also scheduled_events.py).
  [V] : Done
* Main Synapse code (could be adapted from NeuronGroup, Connection and STDP).
* Access and construction.
  [V] : Done

Low priority:
* Possibly, array class with overloaded self addition/multiplication
* Turning differential equations into event-driven code.
* Lumped variable code.
* Synaptic monitoring (should be trivial after access/construction is done).
* GPU code
* Spike queue, C version.
